跳到主要内容

SpringBoot 数据校验

参考资料 springboot-guide 参考资料 使用spring validation完成数据后端校验 参考资料 使用Spring Validation优雅地校验参数

为什么需要数据校验

数据的校验是交互式网站一个不可或缺的功能,前端的js校验可以涵盖大部分的校验职责,如用户名唯一性,生日格式,邮箱格式校验等等常用的校验。但是为了避免用户绕过浏览器,使用http工具直接向后端请求一些违法数据,服务端的数据校验也是必要的,可以防止脏数据落到数据库中

简述 JSR303/JSR-349,hibernate validation,spring validation之间的关系。

JSR303是一项标准,JSR-349是其的升级版本,添加了一些新特性,他们规定一些校验规范即校验注解,如 @Null@NotNull@Pattern,他们位于 javax.validation.constraints 包下,只提供规范不提供实现

hibernate validation 是对这个规范的实践(不要将 hibernate 和数据库 orm 框架联系在一起),他提供了相应的实现,并增加了一些其他校验注解,如 @Email@Length@Range 等等,他们位于 org.hibernate.validator.constraints 包下。

而万能的 Spring 为了给开发者提供便捷,对 hibernate validation 进行了二次封装,显示校验 validated bean 时,可以使用Spring validation 或者 hibernate validation,而 Spring validation 另一个特性,便是其在 SpringMVC 模块中添加了自动校验,并将校验信息封装进了特定的类中。

配置环境

只需要引入 Spring-boot-starter-web 依赖即可

<!-- 引入 SpringMVC -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

它内部已经包含了这个校验工具

所以说 web 模块使用了 hibernate-validation

但是从 springboot-2.3 开始,校验包被独立成了一个starter组件(参见:validation-starter-no-longer-included-in-web-starters),所以需要引入如下依赖:

<!--校验组件-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!--web组件-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

而 springboot-2.3 之前的版本只需要引入 web 依赖就可以了

框架已经提供了的校验

JSR提供的校验注解:       
@Null 被注释的元素必须为 null
@NotNull 被注释的元素必须不为 null
@AssertTrue 被注释的元素必须为 true
@AssertFalse 被注释的元素必须为 false
@Min(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@Max(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@DecimalMin(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@DecimalMax(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@Size(max=, min=) 被注释的元素的大小必须在指定的范围内
@Digits (integer, fraction) 被注释的元素必须是一个数字,其值必须在可接受的范围内
@Past 被注释的元素必须是一个过去的日期
@Future 被注释的元素必须是一个将来的日期
@Pattern(regex=,flag=) 被注释的元素必须符合指定的正则表达式


Hibernate Validator提供的校验注解:
@NotBlank(message =) 验证字符串非null,且长度必须大于0
@Email 被注释的元素必须是电子邮箱地址
@Length(min=,max=) 被注释的字符串的大小必须在指定的范围内
@NotEmpty 被注释的字符串的必须非空
@Range(min=,max=,message=) 被注释的元素必须在合适的范围内

构建启动类

无需添加其他注解,一个典型的启动类

@SpringBootApplication
public class ValidateApp {

public static void main(String[] args) {
SpringApplication.run(ValidateApp.class, args);
}
}

创建需要被校验的实体类

public class Foo {

@NotBlank
private String name;

@Min(18)
private Integer age;

@Pattern(regexp = "^1(3|4|5|7|8)\\d{9}$",message = "手机号码格式错误")
@NotBlank(message = "手机号码不能为空")
private String phone;

@Email(message = "邮箱格式错误")
private String email;

//... getter setter

}

Pattern 注解用于正则表达式

在 @Controller 中校验数据

SpringMVC 为我们提供了自动封装表单参数的功能,一个添加了参数校验的典型 Controller 如下所示。

@Controller
public class FooController {

@RequestMapping("/foo")
public String foo(@Validated Foo foo <1>, BindingResult bindingResult <2>) {
// 这个 BindingResult 内部保存了全部的错误信息
if(bindingResult.hasErrors()){
for (FieldError fieldError : bindingResult.getFieldErrors()) {
//...
}
return "fail";
}
return "success";
}

}

值得注意的地方(这里的 <1><2>):

1、参数 Foo 前需要加上 @Validated 注解,表明需要 Spring 对其进行校验,而校验的信息会存放到其后的 BindingResult 中。注意,必须相邻,如果有多个参数需要校验,形式可以如下。

foo(@Validated Foo foo, BindingResult fooBindingResult ,@Validated Bar bar, BindingResult barBindingResult);

即一个校验类对应一个校验结果。

2、校验结果会被自动填充,在 Controller 中可以根据业务逻辑来决定具体的操作,如跳转到错误页面。

全局异常处理

如果每个 Controller 方法中都写一遍对 BindingResult 信息的处理,使用起来还是很繁琐。可以通过全局异常处理的方式统一处理校验异常。

当我们写了 @validated 注解,不写 BindingResult 的时候,Spring 就会抛出异常。由此,可以写一个全局异常处理类来统一处理这种校验异常,从而免去重复组织异常信息的代码。

全局异常处理类只需要在类上标注 @RestControllerAdvice,并在处理相应异常的方法上使用 @ExceptionHandler 注解,写明处理哪个异常即可。

@RestControllerAdvice
public class GlobalControllerAdvice {
private static final String BAD_REQUEST_MSG = "客户端请求参数错误";

// <1> 处理 form data方式调用接口校验失败抛出的异常
@ExceptionHandler(BindException.class)
public ResultInfo bindExceptionHandler(BindException e) {
List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
List<String> collect = fieldErrors.stream()
.map(o -> o.getDefaultMessage())
.collect(Collectors.toList());
return new ResultInfo().success(HttpStatus.BAD_REQUEST.value(), BAD_REQUEST_MSG, collect);
}

// <2> 处理 json 请求体调用接口校验失败抛出的异常
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResultInfo methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {
List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
List<String> collect = fieldErrors.stream()
.map(o -> o.getDefaultMessage())
.collect(Collectors.toList());
return new ResultInfo().success(HttpStatus.BAD_REQUEST.value(), BAD_REQUEST_MSG, collect);
}

// <3> 处理单个参数校验失败抛出的异常
@ExceptionHandler(ConstraintViolationException.class)
public ResultInfo constraintViolationExceptionHandler(ConstraintViolationException e) {
Set<ConstraintViolation<?>> constraintViolations = e.getConstraintViolations();
List<String> collect = constraintViolations.stream()
.map(o -> o.getMessage())
.collect(Collectors.toList());
return new ResultInfo().success(HttpStatus.BAD_REQUEST.value(), BAD_REQUEST_MSG, collect);
}
}

事实上,在全局异常处理类中,可以写多个异常处理方法,这里总结了三种参数校验时可能引发的异常:

  1. 使用form data方式调用接口,校验异常抛出 BindException
  2. 使用 json 请求体调用接口,校验异常抛出 MethodArgumentNotValidException
  3. 单个参数校验异常抛出ConstraintViolationException

注:单个参数校验需要在参数上增加校验注解,并在类上标注 @Validated

全局异常处理类可以添加各种需要处理的异常,比如添加一个对 Exception.class 的异常处理,当所有 ExceptionHandler 都无法处理时,由其记录异常信息,并返回友好提示。

分组校验

如果同一个参数,需要在不同场景下应用不同的校验规则,就需要用到分组校验了。比如:新注册用户还没起名字,我们允许 name 字段为空,但是不允许将名字更新为空字符。

分组校验有三个步骤:

  1. 定义一个分组类(或接口)
  2. 在校验注解上添加 groups 属性指定分组
  3. Controller 方法的 @Validated 注解添加分组类

定义一个分组接口

public interface Update extends Default{
}

使用这个接口区分组

public class UserVO {
@NotBlank(message = "name 不能为空",groups = Update.class)
private String name;
// 省略其他代码...
}
@PostMapping("update")
public ResultInfo update(@Validated({Update.class}) UserVO userVO) {
return new ResultInfo().success(userVO);
}

自定义的 Update 分组接口继承了 Default 接口。校验注解(如: @NotBlank )和 @validated 默认都属于 Default.class 分组

在编写 Update 分组接口时,如果继承了 Default,下面两个写法就是等效的:

@Validated({Update.class})

@Validated({Update.class,Default.class})

递归校验

如果 UserVO 类中增加一个 OrderVO 类的属性,而 OrderVO 中的属性也需要校验,就用到递归校验了,只要在相应属性上增加 @Valid 注解即可实现(对于集合同样适用)

OrderVO 类如下

public class OrderVO {
@NotNull
private Long id;
@NotBlank(message = "itemName 不能为空")
private String itemName;
// 省略其他代码...
}

在 UserVO 类中增加一个 OrderVO 类型的属性

public class UserVO {
@NotBlank(message = "name 不能为空",groups = Update.class)
private String name;
//需要递归校验的OrderVO
@Valid
private OrderVO orderVO;
// 省略其他代码...
}

自定义校验

Spring 的 validation 为我们提供了这么多特性,几乎可以满足日常开发中绝大多数参数校验场景了。但是,一个好的框架一定是方便扩展的。有了扩展能力,就能应对更多复杂的业务场景,毕竟在开发过程中,唯一不变的就是变化本身。

Spring Validation允许用户自定义校验,实现很简单,分两步:

  1. 自定义校验注解
  2. 编写校验者类
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {HaveNoBlankValidator.class})// 标明由哪个类执行校验逻辑
public @interface HaveNoBlank {

// 校验出错时默认返回的消息
String message() default "字符串中不能含有空格";

Class<?>[] groups() default { };

Class<? extends Payload>[] payload() default { };

/**
* 同一个元素上指定多个该注解时使用
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
public @interface List {
NotBlank[] value();
}
}

校验类

public class HaveNoBlankValidator implements ConstraintValidator<HaveNoBlank, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// null 不做检验
if (value == null) {
return true;
}
if (value.contains(" ")) {
// 校验失败
return false;
}
// 校验成功
return true;
}
}

自定义校验注解使用起来和内置注解无异,在需要的字段上添加相应注解即可